前面三天把型別、Pydantic 和 async 補起來之後,今天來到 Python 地基的最後一天。
接下來寫 MCP 時,會一直看到這三種東西:
@mcp.tool()
async with ...
async for ...
第一次看到 SDK 的寫法,很容易覺得:
這些東西到底在背後做了什麼?
所以今天不急著碰 MCP SDK。
先自己拆一次。
最簡單的 Decorator,本質上就是:
吃一個 function,再回傳一個 function。
例如:
def timed(fn):
def wrapper(*args, **kwargs):
print("開始執行")
result = fn(*args, **kwargs)
print("執行完成")
return result
return wrapper
當我們寫:
@timed
def hello():
print("hello")
其實差不多等於:
hello = timed(hello)
所以 @xxx 本身沒有什麼魔法。
它只是讓我們可以在 function 外面再包一層行為。
這也開始有點像之後會看到的:
@mcp.tool()
今天真正想做的是這個:
@tool
def read_file(
path: str,
max_lines: int = 200
) -> str:
"""讀取專案內的一個檔案"""
希望加上 @tool 之後,可以自動知道:
工具名稱
參數名稱
參數型別
預設值
哪些參數必填
工具說明
做法其實沒有想像中複雜。
Python 有一個很好用的東西:
inspect.signature()
它可以直接把 function 的參數讀出來。
例如:
sig = inspect.signature(read_file)
就能知道:
path → str
max_lines → int,預設 200
接著再用 Pydantic 動態建立 Model:
model = create_model(
"read_file_params",
path=(str, ...),
max_lines=(int, 200)
)
最後:
schema = model.model_json_schema()
就拿到 JSON Schema 了。
所以簡化後的 @tool 大概就是:
def tool(fn):
sig = inspect.signature(fn)
fields = {}
for name, param in sig.parameters.items():
annotation = param.annotation
if param.default is inspect.Parameter.empty:
fields[name] = (annotation, ...)
else:
fields[name] = (
annotation,
param.default
)
model = create_model(
f"{fn.__name__}_params",
**fields
)
schema = model.model_json_schema()
REGISTRY[fn.__name__] = {
"name": fn.__name__,
"description": inspect.getdoc(fn) or "",
"schema": schema,
"handler": fn
}
return fn
做的事情其實就三步:
讀 Function Signature
↓
建立 Pydantic Model
↓
產生 JSON Schema
是不是突然就沒那麼神秘了。
Day 2 我們是直接定義:
class ReadFileParams(BaseModel):
path: str
max_lines: int = 200
今天則是:
@tool
def read_file(
path: str,
max_lines: int = 200
):
...
最後把兩邊產生的 Schema 拿來比較。
實測:
properties 完全相同? ✓ 是
required 相同? True
這就是我今天最想確認的事情。
前幾天看起來像是在學:
Type Hint
Pydantic
Decorator
現在其實已經慢慢接在一起了:
Python Function
↓
Type Hint
↓
Decorator 讀取 Signature
↓
Pydantic
↓
JSON Schema
↓
Tool
等到 Day 10 真正寫:
@mcp.tool()
至少已經知道其中一部分到底在做什麼。
另一個之後很常看到的是:
async with ...
Context Manager 最重要的用途其實很單純:
確保資源有正確地建立,也有正確地關掉。
例如:
@asynccontextmanager
async def lifespan():
print("啟動")
try:
yield
finally:
print("關閉")
使用時:
async with lifespan():
print("程式執行中")
概念大概就是:
yield 之前
→ 建立資源
yield
→ 程式執行
yield 之後
→ 清理資源
就算中間發生錯誤,finally 還是會跑。
到了 Day 10 寫 MCP Server 時,會再看到同樣的概念用在 Server 的生命週期。
最後一個是 Generator。
例如:
def countdown(n):
while n > 0:
yield n
n -= 1
呼叫:
gen = countdown(3)
這時其實還沒有把:
3
2
1
全部算好。
而是你每次要求下一個值,它才繼續往下跑。
next(gen)
得到:
3
再一次才是:
2
這就是 streaming 很重要的一個概念:
不用等全部結果完成,可以邊產生邊處理。
LLM 串流也是一樣。
不是等整段回答生成完成才一次丟回來,而是:
產生一小段
↓
送出
↓
再產生一小段
↓
再送出
如果是 Async Generator,就會看到:
async for chunk in stream():
print(chunk)
這種寫法後面也會一直出現。
今天其實是在把幾個看起來有點「魔法」的 Python 語法拆掉:
Decorator
→ 幫 function 加能力
Context Manager
→ 管理資源的建立與清理
Generator
→ 一次產生一部分結果
而最重要的是,我們真的自己刻出了一個:
@tool
它做的核心流程就是:
inspect
↓
讀取 function signature
↓
Pydantic
↓
JSON Schema
↓
註冊成 Tool
等到 Day 10 使用 MCP SDK 時,再回頭看:
@mcp.tool()
應該就不會覺得它是一個黑盒子了。
下一篇:
用 uv 建專案 + 呼叫第一支 LLM API
前四天都還在準備地基。
明天終於要真的把模型接進來。
會從最基本的請求開始,建立後面整個系列都會共用的 LLM 呼叫層。
也就是從 Day 5 開始,我們不只是在準備零件。
專案要正式開始動了。